view.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. 'use client';
  2. import './style.scss';
  3. import Link from 'next/link';
  4. import { useRouter } from 'next/navigation';
  5. import { useState, useEffect, useCallback, useRef, FormEvent } from 'react';
  6. import Loading from '@/app/component/Loading';
  7. import { BoardLayout, PostConst } from '@/constants/forum';
  8. import { fetchBoard } from '@/lib/api/forum/board';
  9. import { fetchPostUpdate } from '@/lib/api/forum/post';
  10. import { throwError } from '@/lib/utils/client';
  11. import BoardResponse from '@/dtos/response/forum/board/boardResponse';
  12. import BoardListResponse from '@/dtos/response/forum/board/boardListResponse';
  13. import PostResponse from '@/dtos/response/forum/post/postResponse';
  14. import Editor, { Handle } from '../../_component/Editor';
  15. import HeaderContent from '../../_component/HeaderContent';
  16. import FooterContent from '../../_component/FooterContent';
  17. import PostTagInput from '../../_component/PostTagInput';
  18. type Props = {
  19. _boardList: BoardListResponse[],
  20. _board: BoardResponse,
  21. _post: PostResponse
  22. };
  23. export default function View({ _boardList, _board, _post }: Props)
  24. {
  25. const router = useRouter();
  26. const editorRef = useRef<Handle>(null);
  27. const [error, setError] = useState<string|null>(null);
  28. const [loading, setLoading] = useState<boolean>(false);
  29. const [isChanged, setIsChanged] = useState<boolean>(false);
  30. const [board, setBoard] = useState<BoardResponse|null>(_board);
  31. const [boardCode, setBoardCode] = useState<string>(_post.boardCode);
  32. const [boardPrefixID, setBoardPrefixID] = useState<string>(_post.boardPrefixID?.toString() ?? '');
  33. const [subject, setSubject] = useState<string>(_post.subject);
  34. const [content, setContent] = useState<string>(_post.content);
  35. const [isSecret, setIsSecret] = useState<boolean>(_post.isSecret);
  36. const [isNotice, setIsNotice] = useState<boolean>(_post.isNotice);
  37. const [isSpeaker, setIsSpeaker] = useState<boolean>(_post.isSpeaker);
  38. const [tags, setTags] = useState<string[]>(_post.tagList.map((tag) => tag.slug));
  39. const boardCodeRef = useRef<HTMLSelectElement>(null);
  40. const boardPrefixIDRef = useRef<HTMLSelectElement>(null);
  41. const subjectRef = useRef<HTMLInputElement>(null);
  42. const contentRef = useRef<HTMLTextAreaElement>(null);
  43. const redirectUrl = `/post/${_post.id}`;
  44. useEffect(() => {
  45. if (error) {
  46. alert(error);
  47. setError(null);
  48. }
  49. }, [error]);
  50. // 게시글 초기화
  51. const resetForm = () => {
  52. setError('');
  53. setIsChanged(false);
  54. setBoardPrefixID('');
  55. setSubject(board?.boardMeta.write.defaultSubject || '');
  56. setContent(board?.boardMeta.write.defaultContent || '');
  57. setIsSecret(false);
  58. setIsNotice(false);
  59. setIsSpeaker(false);
  60. setTags([]);
  61. // Editor 초기화
  62. if (editorRef.current?.editorInstance) {
  63. editorRef.current.editorInstance.setData(content);
  64. }
  65. };
  66. // 게시판 선택 시
  67. const handleBoardChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
  68. const code = e.target.value;
  69. if (code) {
  70. return;
  71. }
  72. if (isChanged) {
  73. if (!confirm('작성 중인 내용이 사라질 수 있습니다. 게시판을 변경하시겠습니까?')) {
  74. return
  75. }
  76. }
  77. setLoading(true);
  78. fetchBoard(boardCode).then((res) => {
  79. if (res.ok) {
  80. setBoardCode(code);
  81. setBoard(res.data);
  82. setIsChanged(false);
  83. resetForm();
  84. } else {
  85. throw new Error('게시판을 조회할 수 없습니다.');
  86. }
  87. }).catch((err) => {
  88. setError(err.message);
  89. }).finally(() => {
  90. setLoading(false);
  91. });
  92. };
  93. // 제목, 내용, 말머리 변경 시
  94. const handleChange = (e: React.ChangeEvent<HTMLSelectElement|HTMLInputElement|HTMLTextAreaElement>) => {
  95. const { name, value } = e.target as HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement;
  96. const checked = (e.target as HTMLInputElement).checked;
  97. switch (name) {
  98. case 'boardPrefixID':
  99. setBoardPrefixID(value);
  100. break;
  101. case 'isSecret':
  102. setIsSecret(checked);
  103. break;
  104. case 'isNotice':
  105. setIsNotice(checked);
  106. setIsSpeaker(false);
  107. break;
  108. case 'isSpeaker':
  109. setIsSpeaker(checked);
  110. setIsNotice(false);
  111. break;
  112. case 'subject':
  113. setSubject(value);
  114. break;
  115. case 'content':
  116. setContent(value);
  117. break;
  118. }
  119. setIsChanged(true);
  120. };
  121. // CKEditor에서 내용 변경 시
  122. const handleEditorChange = useCallback((data: string) => {
  123. setContent(data);
  124. setIsChanged(true);
  125. }, []);
  126. const validate = () => {
  127. if (!boardCode || !board) {
  128. boardCodeRef.current!.focus();
  129. throw new Error('게시판을 선택해주세요.');
  130. }
  131. if (board.boardMeta.write.allowPrefix && board.boardMeta.write.requiredPrefix && !boardPrefixID) {
  132. boardPrefixIDRef.current!.focus();
  133. throw new Error((board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + '를 선택해주세요.');
  134. }
  135. if (!subject) {
  136. subjectRef.current!.focus();
  137. throw new Error('제목을 입력해주세요.');
  138. } else if (subject.length > PostConst.maxAllowedSubjectLength) {
  139. subjectRef.current!.focus();
  140. throw new Error(`제목은 ${PostConst.maxAllowedSubjectLength}자 이내로 작성해주세요.`);
  141. }
  142. if (!content) {
  143. if (board.boardMeta.write.allowEditor) {
  144. editorRef.current!.editorInstance?.editing.view.focus();
  145. } else {
  146. contentRef.current!.focus();
  147. }
  148. throw new Error('내용을 입력해주세요.');
  149. } else if (!board.boardMeta.write.allowEditor) {
  150. // 기본 textarea 사용 시 글자 수 검사
  151. if (content.length > PostConst.maxAllowedContentLength) {
  152. contentRef.current!.focus();
  153. throw new Error(`내용은 ${PostConst.maxAllowedContentLength}자 이내로 작성해주세요.`);
  154. }
  155. }
  156. if (board.boardMeta.write.allowTag && tags.length > board.boardMeta.write.tagLimit) {
  157. throw new Error(`태그는 ${board.boardMeta.write.tagLimit}개 이내로 작성해주세요.`);
  158. }
  159. };
  160. // 게시글 등록 처리
  161. const handleSubmit = useCallback(async (e: FormEvent) => {
  162. e.preventDefault();
  163. try {
  164. validate();
  165. setLoading(true);
  166. if (!board) {
  167. throw new Error('게시판을 선택해 주세요.');
  168. }
  169. const formData = new FormData();
  170. formData.append('postID', _post.id.toString());
  171. formData.append('boardID', board.id.toString());
  172. formData.append('boardCode', boardCode);
  173. formData.append('boardPrefixID', boardPrefixID);
  174. formData.append('isSecret', isSecret.toString());
  175. formData.append('isNotice', isNotice.toString());
  176. formData.append('isSpeaker', isSpeaker.toString());
  177. formData.append('subject', subject);
  178. if (content) {
  179. const doc = new DOMParser().parseFromString(content, 'text/html');
  180. doc.querySelectorAll('img[src]').forEach(img => {
  181. const src = img.getAttribute('src');
  182. if (src && src.startsWith('data:image/')) {
  183. img.setAttribute('src', 'data:image/');
  184. }
  185. });
  186. formData.append('content', doc.body.innerHTML);
  187. }
  188. // 태그
  189. if (board.boardMeta.write.allowTag) {
  190. tags.forEach(tag => formData.append('tags', tag));
  191. }
  192. // 이미지 정보
  193. if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowImage) {
  194. editorRef.current?.getImageStore().forEach(i => {
  195. if (i.image?.size > 0 && i.name) {
  196. formData.append('images', i.image, i.name);
  197. }
  198. });
  199. }
  200. // 미디어 정보
  201. if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowMedia) {
  202. editorRef.current!.getMediaStore().forEach((m) => {
  203. if (m.url) {
  204. formData.append('medias', m.url);
  205. }
  206. });
  207. }
  208. // 첨부 파일
  209. if (board.boardMeta.write.allowEditor && board.boardMeta.write.allowFile) {
  210. editorRef.current!.getFileStore().forEach(f => {
  211. if (f?.size > 0 && f.name) {
  212. formData.append('files', f.file, f.name);
  213. }
  214. });
  215. }
  216. const res = await fetchPostUpdate(formData);
  217. if (res.ok) {
  218. resetForm();
  219. router.push(redirectUrl);
  220. } else {
  221. throwError(res);
  222. }
  223. } catch (err: any) {
  224. setError(err.message);
  225. } finally {
  226. setLoading(false);
  227. }
  228. }, [boardCode, board, boardPrefixID, subject, content, isSecret, isNotice, isSpeaker, tags]);
  229. return (
  230. <form id='postEdit' onSubmit={handleSubmit}>
  231. {loading && <Loading />}
  232. <fieldset>
  233. <legend><h1>{board?.name} 글 수정</h1></legend>
  234. {/* 상단 안내 */}
  235. {<HeaderContent isEnabled={board?.boardMeta.write.showHeader} content={board?.boardMeta.write.headerContent}/>}
  236. {/* 게시판 선택, 말머리, 비밀글, 공지, 전체 공지 */}
  237. <section>
  238. {/* 게시판 선택 */}
  239. <article>
  240. <select name='boardCode' ref={boardCodeRef} value={boardCode} onChange={handleBoardChange} title='게시판 선택'>
  241. {_boardList.map((board) => (
  242. <option key={board.code} value={board.code}>{board.name}</option>
  243. ))}
  244. </select>
  245. </article>
  246. {/* 말머리 */}
  247. {board?.boardMeta.write.allowPrefix && (
  248. <article>
  249. <select name='boardPrefixID' ref={boardPrefixIDRef} value={boardPrefixID} onChange={handleChange} title='말머리 선택'>
  250. <option value=''>{(board.boardMeta.list.layout === BoardLayout.QnA ? '분류' : '말머리') + ' 선택'}</option>
  251. {board.boardPrefix.map((row) => (
  252. <option key={row.id} value={row.id}>{row.name}</option>
  253. ))}
  254. </select>
  255. </article>
  256. )}
  257. <article>
  258. {/* 비밀글 */}
  259. {board?.boardMeta.write.allowSecret && (
  260. <>
  261. <input type='checkbox' name='isSecret' id='isSecret' checked={isSecret} onChange={handleChange} />
  262. <label htmlFor='isSecret'>비밀글</label>
  263. </>
  264. )}
  265. {/* 해당 게시판 공지 */}
  266. <input type='checkbox' name='isNotice' id='isNotice' checked={isNotice} onChange={handleChange} />
  267. <label htmlFor='isNotice'>공지</label>
  268. {/* 게시판 전체 공지 */}
  269. <input type='checkbox' name='isSpeaker' id='isSpeaker' checked={isSpeaker} onChange={handleChange} />
  270. <label htmlFor='isSpeaker'>전체 공지</label>
  271. </article>
  272. </section>
  273. {/* 제목 */}
  274. <section>
  275. <input type='text' name='subject' ref={subjectRef} value={subject} onChange={handleChange} placeholder='글 제목을 입력해주세요.' autoFocus maxLength={PostConst.maxAllowedSubjectLength} />
  276. </section>
  277. {/* 내용 */}
  278. <section>
  279. {board?.boardMeta.write.allowEditor ?
  280. (
  281. <Editor ref={editorRef} key={boardCode} data={content} onChange={handleEditorChange} boardMeta={board?.boardMeta} />
  282. ) : (
  283. <textarea name='content' ref={contentRef} value={content} onChange={handleChange} placeholder='내용을 입력해주세요.' maxLength={PostConst.maxAllowedContentLength}></textarea>
  284. )}
  285. </section>
  286. {/* 태그 */}
  287. {board?.boardMeta.write.allowTag && (
  288. <section id='postTag'>
  289. <PostTagInput value={tags} onChange={setTags} maxTags={board.boardMeta.write.tagLimit} />
  290. </section>
  291. )}
  292. {/* 하단 안내 */}
  293. {<FooterContent isEnabled={board?.boardMeta.write.showFooter} content={board?.boardMeta.write.footerContent}/>}
  294. <br/>
  295. <section>
  296. <button type='submit' className='btn btn-submit' disabled={loading}>
  297. { loading ? '수정 중…' : '확인' }
  298. </button>
  299. <Link href={redirectUrl} className='btn btn-default'>취소</Link>
  300. </section>
  301. </fieldset>
  302. </form>
  303. );
  304. }